Four layers, not seven. This is the stack actually implemented in operating-system kernels, and the one packet captures show on the wire.
Send: each layer adds its header on the way down. Receive: the reverse, peel a header at each layer going up.
| Layer | Job | Examples |
|---|---|---|
| Application | App protocol; payload your code reads and writes | HTTP, DNS, SSH, TLS, gRPC |
| Transport | Port-to-port delivery; reliability, ordering, congestion control | TCP, UDP, QUIC |
| Internet | Host-to-host routing across networks | IPv4, IPv6, ICMP |
| Link | Frames on a single physical or wireless segment | Ethernet, Wi-Fi, ARP, the NIC driver |
The older 7-layer OSI model splits Application into Application/Presentation/Session, but nobody implements those as distinct layers. TLS is part of the app, framing is in the link, sessions belong to the protocol.
| TCP | UDP | |
|---|---|---|
| Connection | Yes (3-way handshake) | No |
| Reliability | Guaranteed delivery | Best-effort |
| Ordering | In-order | None |
| Header | 20–60 bytes | 8 bytes |
| Use cases | HTTP, SSH, email | DNS, video, VoIP |
| Service | Port | Service | Port |
|---|---|---|---|
| HTTP | 80 | HTTPS | 443 |
| SSH | 22 | Telnet | 23 |
| FTP | 21 | SMTP | 25 |
| DNS | 53 | DHCP | 67/68 |
| POP3 | 110 | IMAP | 143 |
| IPv4 | IPv6 | |
|---|---|---|
| Address size | 32 bits | 128 bits |
| Notation | 192.168.1.1 | 2001:db8::1 |
| Header | 20–60 bytes | 40 bytes (fixed) |
| Address space | ~4.3 × 109 | ~3.4 × 1038 |
iperf3 measures end-to-end throughput between two hosts. One side runs as a server, the other as a client. What you get back is the bandwidth the network actually delivers, not the line rate printed on the cable.
| Command | What it does |
|---|---|
iperf3 -s | Run a server on default port 5201 |
iperf3 -c host -t 30 | 30-second TCP test against host |
iperf3 -c host -P 4 | 4 parallel streams; useful on high-BDP links |
iperf3 -c host -u -b 100M | UDP at 100 Mbit/s; reports jitter and loss |
iperf3 -c host -R | Reverse mode: server sends, client receives |
iperf3 -c host -w 4M | Override TCP window size (skip autotune) |
iperf3 -c host -J | Emit JSON for scripts and dashboards |
A single TCP stream is often capped by the bandwidth-delay product and the receive window, not by the NIC. Use -P to find the link ceiling. For jitter and packet loss, use UDP; TCP hides loss inside retransmits.
| Column | Meaning |
|---|---|
| Bitrate | Throughput over the interval |
| Retr | TCP retransmissions; non-zero means loss or queue overflow |
| Cwnd | Sender's congestion window (TCP) |
| Jitter | Variation in inter-packet arrival time (UDP) |
| Lost/Total | UDP packet loss ratio |
Every NIC in Linux shows up as a net_device (eth0, wlan0, lo). The driver registers it with the kernel; the stack talks to it through a small set of callbacks called net_device_ops. Packets travel through as sk_buff structs (skbs), with the headers walked using offset pointers.
| Symbol | What it is |
|---|---|
net_device | Kernel object per interface (eth0, wlan0, lo); created with alloc_etherdev(), registered with register_netdev() |
net_device_ops | Vtable of driver callbacks: ndo_open, ndo_stop, ndo_start_xmit, ndo_get_stats64, etc. |
sk_buff (skb) | Packet container with payload, header offsets, length, device pointer, and timestamps |
napi_struct | NAPI context; napi_schedule() from the IRQ, napi_complete_done() when the ring is drained |
| qdisc | Queueing discipline between IP and the driver: pfifo_fast, fq, fq_codel |
| DMA ring | Fixed array of descriptors; head/tail pointers cycle as packets are produced and consumed |
static const struct net_device_ops my_ops = {
.ndo_open = my_open, /* ifconfig up: allocate rings, request IRQ */
.ndo_stop = my_stop, /* ifconfig down: free rings, drop IRQ */
.ndo_start_xmit = my_xmit, /* TX one skb: map DMA, write descriptor */
.ndo_get_stats64 = my_stats, /* 64-bit byte/packet counters */
};
static int my_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
struct net_device *ndev = alloc_etherdev(sizeof(struct my_priv));
ndev->netdev_ops = &my_ops;
netif_napi_add(ndev, &priv->napi, my_poll, NAPI_POLL_WEIGHT);
return register_netdev(ndev);
}
| Command | What it shows |
|---|---|
ip link | List interfaces, link state, MAC, MTU |
ip -s link show eth0 | RX/TX byte and error counters |
ethtool eth0 | Link speed, duplex, supported modes |
ethtool -i eth0 | Driver name, version, firmware |
ethtool -S eth0 | Per-driver stats (queue counters, NAPI polls, drops) |
ethtool -g eth0 | Ring sizes, current and maximum |
ethtool -k eth0 | Offloads: TSO, GRO, checksum, RSS |
tcpdump -i eth0 | Tap above the driver via libpcap |
ss -ti | Per-socket TCP info: cwnd, rtt, retrans |
Why NAPI exists: at 10 Gbit/s a small-packet workload can push over a million packets per second. One IRQ per packet would melt a CPU. The driver raises a single interrupt, then a softirq drains the ring in a poll loop until it is empty or the budget is hit. Interrupts get re-enabled only when there is nothing left to do.
A Linux board without built-in Wi-Fi can still expose a normal wlan0 to user space. The trick: an MCU on the other end of an SPI bus carries the radio, and a small kernel driver makes the SPI link look like a regular netdev.
| Mode | IP / TCP | Host CPU | Trade-off |
|---|---|---|---|
| Thin co-processor (mac80211 style) | Linux kernel | Higher: every packet crosses the SPI bus and the stack | Full kernel features available: iptables, tc, eBPF, ss, conntrack |
| Full TCP/IP offload | MCU (LwIP or similar) | Lower: host sends "open socket / send N bytes" over SPI | Fewer kernel features; fixed socket count; offload firmware to debug |
| Step | What happens |
|---|---|
app: write() | Bytes copied into the socket send buffer |
TCP / IP | Segmentation, headers, route lookup; output is an skb pointed at wlan0 |
qdisc | Per-device queue: pfifo_fast, fq, fq_codel |
ndo_start_xmit | SPI driver wraps the skb with a length / type header and queues it |
| SPI master | DMAs the bytes out on MOSI while reading any pending RX bytes on MISO |
| MCU firmware | Strips the framing header, hands the payload to its Wi-Fi MAC |
| 802.11 MAC + PHY | Builds the frame, runs CSMA/CA, transmits on the channel |
RX is the reverse. The MCU asserts the INT line; the host's GPIO IRQ fires (the INT line is an out-of-band wire, not the SPI controller's own interrupt); the driver does napi_schedule; the NAPI poll then runs spi_sync() in a loop until the MCU says it has nothing more; each frame goes up via netif_receive_skb.
Raw SPI has no concept of packet boundaries: it just clocks bytes. Every co-processor protocol therefore prefixes each transfer with a small header (length, type, sequence, sometimes a CRC). Full-duplex SPI is genuinely full-duplex: while the host clocks out a TX frame, the MCU clocks back any RX it had queued. The INT line is the MCU saying "I have something for you; please drive SCLK".
/* Match the MCU on the SPI bus (devicetree compatible = "espressif,esp-hosted") */
static const struct of_device_id wifispi_of_match[] = {
{ .compatible = "espressif,esp-hosted" }, { /* sentinel */ }
};
static netdev_tx_t wifispi_xmit(struct sk_buff *skb, struct net_device *ndev)
{
struct wifispi *priv = netdev_priv(ndev);
/* Prepend framing: 16-bit length, 8-bit type, 8-bit seq */
wifispi_frame(priv, skb);
spi_async(priv->spi, &priv->tx_msg); /* DMA the bytes; non-blocking */
return NETDEV_TX_OK;
}
static irqreturn_t wifispi_irq(int irq, void *dev_id)
{
struct wifispi *priv = dev_id;
napi_schedule(&priv->napi); /* MCU has frames to give us */
return IRQ_HANDLED;
}
static int wifispi_poll(struct napi_struct *napi, int budget)
{
int n = 0;
while (n < budget && wifispi_rx_one(napi)) n++; /* spi_sync inside */
if (n < budget) { napi_complete_done(napi, n); enable_irq(priv->irq); }
return n;
}
static int wifispi_probe(struct spi_device *spi)
{
struct net_device *ndev = alloc_etherdev(sizeof(struct wifispi));
/* ndo_start_xmit -> wifispi_xmit; ndo_open/stop bring the IRQ up/down */
netif_napi_add(ndev, &priv->napi, wifispi_poll, NAPI_POLL_WEIGHT);
return register_netdev(ndev);
}
| Command | What it shows |
|---|---|
dmesg | grep spi | SPI controller probe and the Wi-Fi driver binding to it |
ip link show wlan0 | Link state, MAC, MTU (the SPI link is invisible at this layer) |
ethtool -i wlan0 | Driver name and version (confirms the SPI co-processor driver is in use) |
iw dev wlan0 link | SSID, signal, bitrate negotiated by the MCU |
cat /proc/interrupts | grep spi | SPI IRQ count climbing under load |
iperf3 -c host | End-to-end throughput; useful for finding the SPI clock or framing bottleneck |
Why a board would be built this way: the SBC stays cheap and Wi-Fi-free, the radio module is a pre-certified part you can drop in, and an MCU that was already on the board for sensors can carry the Wi-Fi too. The cost: SPI is the throughput ceiling. A 40 MHz bus delivers tens of Mbit/s once framing and turnaround are paid for, well under what 802.11ac can actually push on air, so this design fits gateways and telemetry, not video.